Skip to content

Go 工程工具选型:沿着一个项目的重复劳动逐个选择 ​

一个 Go 项目可能同时需要 CLI、测试、配置、日志和发布,但这不意味着开仓库的第一天就要把十几个工具全部装上。更有用的顺序是:先找到重复劳动,再检查一个工具怎样消除它、增加了什么约束,以及你能否在 CI 中复现它的行为。

本文用一个报价 CLI 贯穿选择过程。Cobra、Testify、Viper、Koanf、zap 和 slog 有本次固定版本实测;golangci-lint、Testcontainers、Wire、Air、GoReleaser 提供经过固定版本资料核对的入口和完整配置示意,明确没有运行。尤其是本环境没有 Docker,不能把一段容器测试代码写成已验证集成测试。

先看哪一种重复劳动需要工具 ​

目前的问题候选与入口本文版本基线本次验证
子命令、flags、帮助重复编写Cobra:Command、RunEv1.10.1CLI、错误路径与测试
断言重复、失败信息不一致Testify:assert / requirev1.11.1require 断言
多种配置来源整合Viper:New、BindEnv、Getv1.21.0JSON 配置、环境、显式覆盖
希望显式选择加载源与顺序Koanf:New、Loadv2.3.0;confmap v1.0.0相同标量键的顺序覆盖
需要结构化日志slog / zapGo 1.27.2 / v1.27.0字段与 JSON 输出
多种检查器统一管理golangci-lint:run配置参考 v2.5.0文档/配置核查,未运行
需要真实外部系统测试testcontainers-gov0.39.0API/测试示意,未运行
大量依赖图连线重复Wirev0.7.0生成输入示意,未运行;项目已归档
修改代码后总要手动停启Airv1.63.0配置示意,未运行
多目标构建打包重复GoReleaserv2.12.7配置示意,未运行

这些是为了锁定本文 API 与配置的版本,不是“截至今天最新或最安全版本”的推荐清单。实际采用时还要核对支持的 Go 版本和已知问题;特别是静态分析工具,其发布二进制使用的 Go 版本、分析器和目标语言版本是否匹配,需要单独确认。golangci-lint 兼容性 FAQ

固定版本出处:Cobra、Testify、Viper、Koanf、zap。未执行工具的固定配置来源在对应段落给出。

CLI 开始有多个操作时,用 Cobra 管命令边界 ​

如果只有一个简单开关,标准库 flag 已经够用。出现 shop quote、shop inspect 这样的多个操作后,Cobra 能集中管理命令树、参数验证、flags 与帮助输出。业务计算仍适合留在普通函数里,命令层负责转换参数和输出。Cobra v1.10.1

本例创建一个 quote 子命令:

go
quote := &cobra.Command{
    Use: "quote [sku]",
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        if qty < 1 || qty > 100 {
            return fmt.Errorf("quantity must be between 1 and 100")
        }
        _, err := fmt.Fprintf(cmd.OutOrStdout(),
            "sku=%s qty=%d total=%d\n", args[0], qty, 250*qty)
        return err
    },
}

实际运行:

text
$ go run . quote pen --qty=3
sku=pen qty=3 total=750

$ go run . quote pen --qty=0
quantity must be between 1 and 100
exit status 1

RunE 返回错误,程序入口统一打印并决定退出码。例子关闭重复 usage/error 输出,避免同一个错误被 Cobra 与 main 各打印一次。cmd.OutOrStdout() 则让测试能注入 writer,不用替换整个进程 stdout。Command v1.10.1 API

代价是多了命令对象状态和一套框架约定。每个测试创建新命令,避免 flags 状态互相串用;真正复杂的业务不应该只能通过执行整个 CLI 来测试。本文没有生成 shell 补全或安装系统级命令。

Testify 减少断言样板,但不能替你决定测什么 ​

go
root := newRoot()
root.SetOut(&out)
root.SetArgs([]string{"quote", "pen", "--qty=3"})
require.NoError(t, root.Execute())
require.Equal(t, "sku=pen qty=3 total=750\n", out.String())

测试实际通过。require 在失败后立即终止当前测试流程,适合后续无法继续的前置条件;assert 报告失败后通常还能继续,让多个独立结果一次显示。require 的立即失败应在测试所运行的 goroutine 使用,后台任务应把错误传回该流程。Testify v1.11.1、require

选择何时够用要承担什么
if + t.Fatal简单判断,愿意自己写精确错误信息少量重复代码
assert / require常见比较多,希望统一表达与失败输出测试依赖、API 约定和升级
mock 框架交互复杂,确实需要验证调用契约预期设置可能绑定实现细节

Testify 带有 mock 不代表每个依赖都应该 mock。一个方法的小接口,手写 fake 往往更容易读;真实数据库语义则还需要集成测试。新增断言库解决的是表达成本,不能补救错误的预期或缺失的边界用例。

配置来源变多之后:选约定完整的 Viper,还是显式组合的 Koanf ​

只有几个环境变量时,LookupEnv 加解析、校验更直接。需要文件、环境、flags 等来源后,配置库能省下不少合并工作,但“到底哪个值生效”必须仍然可解释。

维度ViperKoanf
常用入口New、SetDefault、ReadConfig、BindEnv、Get、UnmarshalNew、Load、Get、Unmarshal
来源组织集成多种来源和优先级约定provider 提供数据,parser 解析格式,应用安排 Load
本例覆盖方式环境覆盖文件;显式 Set 再覆盖环境后加载的同名标量覆盖前值
需要审查键名映射、空环境值、优先级、并发读写provider/parser 版本、加载顺序、合并规则、并发边界

Viper 的优先级从高到低是显式 Set、flags、环境、配置文件、外部键值存储、默认值。默认不把空环境值当有效值,可用 AllowEmptyEnv 改变;读取 getter 时读取环境的机制也不能等同于冻结快照。Viper v1.21.0

本次先设置默认 8080,读取 JSON 配置值 9000,绑定示例环境变量值 9001,再显式 Set 9002:

text
viper env= 9001
viper explicit= 9002

Koanf 通过两个 confmap provider 依次加载 port=9000、port=9001:

go
k := koanf.New(".")
k.Load(confmap.Provider(map[string]interface{}{"port": 9000}, "."), nil)
k.Load(confmap.Provider(map[string]interface{}{"port": 9001}, "."), nil)
text
koanf later= 9001

这段正文省去了错误检查以突出加载顺序,附录完整程序逐次检查 Load 错误。本次只验证相同标量键覆盖,复杂 map、数组、类型冲突要看所选版本的合并规则,不能从这一个结果推断所有 YAML 合并行为。Koanf v2.3.0、confmap v1.0.0

选择建议:愿意采用一套现成来源约定,可以选 Viper;希望加载步骤全部显式写出,可选 Koanf。两者最终都应转换并验证为应用配置。Viper 文档明确提示同一实例并发读写并不自动安全;热更新要发布一致快照,并处理连接池等资源的重建,不能让业务代码一边读取一边逐字段修改。Viper 并发说明

日志先选契约,再比较 slog 与 zap ​

slog 是标准库 Logger/Handler 接口,自带 JSON 与文本输出;zap 提供类型字段 Logger、便利的 SugaredLogger,以及可组合的 Core/Encoder 等机制。两者都能输出结构化事件,团队已有生态和实际性能需求往往比库名更重要。slog、zap v1.27.0

本例用同一个内存 buffer,分别输出 ready 事件和 port 字段,移除时间以固定结果:

text
zap {"level":"info","msg":"ready","port":9001}
slog {"level":"INFO","msg":"ready","port":9001}

这是输出格式比较,没有做吞吐量、分配或真实磁盘测试,不据此给性能排座次。不同级别文字、时间和字段编码要与采集端约定,别只看到“都是 JSON”就认为切换完全无差异。

zap 的典型入口是 zap.NewProduction()、logger.Info(..., zap.Int(...))、logger.Sync();要更方便的参数形式可用 Sugar,代价和行为看对应文档。本例直接构造 Core 是为了精确控制输出格式,并检查 Sync 的错误。zap Logger

若项目没有额外需求,slog 能减少一个依赖;已有大量 zap 字段和后端集成的项目,也没有必要只因标准库出现替代而仓促重写。无论选谁,敏感信息、请求关联、输出阻塞与采样都需要单独设计。

把本地反馈缩短:golangci-lint 管检查,Air 管重建重启 ​

这两个工具经常都在开发环境里运行,但职责不同。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    E["修改源码"] --> A["Air 观察文件变化"]
    A --> B["执行 build.cmd"]
    B -->|"成功"| R["停止旧进程并运行新程序"]
    E --> L["golangci-lint run"]
    L --> D["输出静态诊断"]
    D --> E
    B -->|"失败"| F["保留构建错误供修复"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    E["修改源码"] --> A["Air 观察文件变化"]
    A --> B["执行 build.cmd"]
    B -->|"成功"| R["停止旧进程并运行新程序"]
    E --> L["golangci-lint run"]
    L --> D["输出静态诊断"]
    D --> E
    B -->|"失败"| F["保留构建错误供修复"]

图中 Air 的旧进程行为还受配置控制;本例 stop_on_error=true 表示构建出错也停止旧程序,不保证服务持续可用。它用于开发反馈,不是生产高可用流程。Air v1.63.0 配置

golangci-lint:规则少而清楚,通常比全开更有用 ​

最小 .golangci.yml:

yaml
version: "2"
linters:
  default: none
  enable: [govet, ineffassign, unused]

入口为 golangci-lint run ./...。这份配置按 v2.5.0 的官方 reference 核对,只明确开启三项;不是在 Go 1.27.2 环境跑过该版本二进制。正式接入先选声明支持项目 Go 版本的工具版本,再固定二进制与配置,执行自己的检查用例。v2.5.0 配置参考、版本兼容 FAQ

收益是统一多个分析器的执行与报告,代价是更多配置、误报处理和升级变化。新增规则时看清检查范围,给例外留下原因,不要为了绿灯整包忽略;也不能把所有 lint 都过了当成业务测试已通过。

Air:没有把新代码热补丁进旧进程 ​

附录的 .air.toml 让它执行 go build -o ./tmp/shop .,随后运行生成的程序。入口是 air -c .air.toml。exclude_dir 排除 tmp、vendor、dist,避免产物变化引发无意义的再次构建。Air 官方项目、固定版本配置

进程重启会失去内存状态,也可能重复执行启动副作用;需要外部持久化的开发数据要明确隔离。Air 可以减少敲命令的次数,不能替代程序的信号处理、资源清理和生产部署。本文没有安装 Air,也没有验证文件监视或重启行为。

fake 无法代表外部系统时,再引入 Testcontainers ​

Testcontainers 管理测试容器的生命周期,让测试可以面对真实 Redis、数据库或其他服务。它需要可用的容器运行环境和镜像获取能力;测试必须等待服务可用,失败后清理资源。Testcontainers Quickstart

v0.39.0 的常见入口是 GenericContainer,用 ContainerRequest 声明镜像、暴露端口与等待策略;随后获取实际 host 和映射端口,不能假设永远是 localhost:6379。固定 API、ContainerRequest

v0.39.0 的 GenericContainer 在创建或启动失败时也可能返回非 nil 容器,因此拿到非 nil 对象就先登记有限时长的清理,再处理初始化错误,避免漏掉部分创建的资源。固定版本失败行为

附录给一个完整但未执行的 Redis 测试:启动容器、等待端口、连接实际映射地址、发送 RESP PING、断言 PONG,最后清理。它使用固定标签 redis:7.4.2-alpine 作为教学输入;本次没有拉取或核验这个镜像,正式 CI 应选经过团队安全检查的版本并固定摘要。端口可连接只是一层就绪条件,协议级 PING 再检查应用响应。端口等待策略 v0.39.0

得到的能力仍然没得到的能力
独立测试依赖、明确创建与清理自动代表生产主从、故障切换或网络条件
真实协议、约束与驱动行为验证自动保证测试数据隔离与断言充分
可写入 CI 的环境准备代码免费的启动时间、磁盘、内存和镜像供应链

因此它适合验证 fake 无法覆盖的边界,不适合把每个纯函数测试都搬进容器。本文没有 Docker,容器示意从未启动,也没有生成容器测试的成功输出。

组装代码很多时看 Wire,但先看到归档状态 ​

Wire 用构造函数作为 provider,通过 wire.Build(...) 描述如何构造结果,再生成普通 Go 初始化代码。手工注入仍是小项目更清楚的起点;重复依赖图很大时,代码生成才可能省下维护连线的工作。Wire v0.7.0 指南

附录保留两份输入:普通构造函数在 app.go;带 wireinject 构建标签的 wire.go 描述 injector。生成入口可固定为 go run github.com/google/wire/cmd/wire@v0.7.0。这只是读懂已有 Wire 项目的最小示意,本文没有下载运行生成器,也没有提供不存在的 wire_gen.go 或声称代码生成成功。

官方仓库在 2025-08-25 归档,README 明确不再维护,已于 2026-10-13 核对。对新项目,优先手工构造函数;如果确需自动化,再核查候选工具或 fork 的维护者、兼容性与生命周期能力。运行时容器与编译期代码生成是不同取舍,不能把另一个知名库名直接当作无成本替换。Wire 当前状态

多平台发布变成固定工作后,用 GoReleaser 编排制品 ​

GoReleaser 可以组织构建矩阵、归档、校验和和发布流程。最小 .goreleaser.yaml 指定 main、binary、GOOS/GOARCH 与 CGO_ENABLED,完整配置在附录。v2.12.7 Go 构建配置

建议接入时先用 goreleaser check 检查配置,再做不发布的本地 snapshot:

bash
goreleaser release --snapshot --clean --skip=publish

这是操作示意,本文未执行。--clean 会清理工具的输出目录,运行前确认其中没有需要保留的手工文件;--skip=publish 明确跳过发布阶段,不能据此推断其他自定义 hooks 没有副作用。固定版本 quick start

它减少多平台打包脚本的重复,但不能解决目标环境测试、cgo 交叉工具链、凭据权限或回滚兼容性。release job 的写权限应与普通 PR 检查分开,源码、构建参数、制品摘要要能对应;最终发布必须经过项目已有的授权流程。

采用一个工具前,先做一个能退出的试用 ​

本文建议每次只回答四件事:它省掉哪段重复劳动;固定哪个版本和配置;最小样例能否通过;撤掉以后是否还有可读的代码或制品。先用 Cobra/Testify 跑通可测试的 CLI,再按实际需要加入配置库、日志后端、外部依赖测试和发布编排,比一次装齐工具更容易找到问题。

维护状态也需要持续核对。本文只对 Wire 明确给出归档结论;其他项目的固定版本资料和当前官方页面已查阅,不把“页面可访问”当作长期维护承诺,也没有声称这些历史基线都是最新安全版本。

证据与实际运行 ​

2026-10-13,Go 1.27.2、Linux/amd64,Cobra v1.10.1、Testify v1.11.1,以及 Viper v1.21.0、Koanf v2.3.0/confmap v1.0.0、zap v1.27.0 的独立模块均经官方 Go 代理与 checksum database 下载核对,go mod verify 输出 all modules verified。两模块的 test、race、vet 通过,CLI 成功/失败输出及配置/日志输出如正文所示。完整 go.mod 和 go.sum 在下方。

未运行:golangci-lint、Air、Testcontainers、Wire、GoReleaser;没有容器、真实配置中心、日志采集平台或远端发布。其配置片段依据各段的固定版本官方资料,不是通过运行认证的整套 Go 1.27.2 工具组合。

完整可运行实验 ​

按下列路径保存,使用 Go 1.27.2,设置 GOTOOLCHAIN=local、GOFLAGS=-buildvcs=false,执行 bash run_tools.sh。脚本只运行 cli 与 configlog 两个模块;后面的 recipes 目录不会被脚本执行。需要联网下载依赖,完整性校验不等同于漏洞扫描。

run_tools.sh ​

bash
#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/tools"
for dir in cli configlog; do
 (
 cd "$dir"
 echo "== $dir pinned dependencies =="
 GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod tidy
 GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download all
 go mod verify
 go test -count=1 -v ./...
 go test -count=1 -race ./...
 go vet ./...
 )
done
cd cli
echo '== cli success =='
go run . quote pen --qty=3
echo '== cli rejected input =='
if go run . quote pen --qty=0; then exit 1; else echo expected_failure=true; fi
cd ../configlog
echo '== configuration and logger comparison =='
go run .

tools/cli/go.mod ​

go.mod
module example.com/v2/toolcli

go 1.27.2

require (
	github.com/spf13/cobra v1.10.1
	github.com/stretchr/testify v1.11.1
)

require (
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/spf13/pflag v1.0.9 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

tools/cli/go.sum ​

text
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

tools/cli/main.go ​

go
package main

import (
	"fmt"
	"github.com/spf13/cobra"
	"os"
)

func newRoot() *cobra.Command {
	root := &cobra.Command{Use: "shop", SilenceUsage: true, SilenceErrors: true}
	qty := 1
	quote := &cobra.Command{Use: "quote [sku]", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
		if qty < 1 || qty > 100 {
			return fmt.Errorf("quantity must be between 1 and 100")
		}
		_, err := fmt.Fprintf(cmd.OutOrStdout(), "sku=%s qty=%d total=%d\n", args[0], qty, 250*qty)
		return err
	}}
	quote.Flags().IntVar(&qty, "qty", 1, "number of items")
	root.AddCommand(quote)
	return root
}
func main() {
	if err := newRoot().Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

tools/cli/main_test.go ​

go
package main

import (
	"bytes"
	"github.com/stretchr/testify/require"
	"testing"
)

func TestQuote(t *testing.T) {
	var out bytes.Buffer
	root := newRoot()
	root.SetOut(&out)
	root.SetArgs([]string{"quote", "pen", "--qty=3"})
	require.NoError(t, root.Execute())
	require.Equal(t, "sku=pen qty=3 total=750\n", out.String())
}
func TestBadQuantity(t *testing.T) {
	root := newRoot()
	root.SetArgs([]string{"quote", "pen", "--qty=0"})
	require.ErrorContains(t, root.Execute(), "quantity must be")
}
func TestMissingSKU(t *testing.T) {
	root := newRoot()
	root.SetArgs([]string{"quote"})
	require.Error(t, root.Execute())
}

tools/configlog/go.mod ​

go.mod
module example.com/v2/configlogtools

go 1.27.2

require (
	github.com/knadh/koanf/providers/confmap v1.0.0
	github.com/knadh/koanf/v2 v2.3.0
	github.com/spf13/viper v1.21.0
	go.uber.org/zap v1.27.0
)

require (
	github.com/fsnotify/fsnotify v1.9.0 // indirect
	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
	github.com/knadh/koanf/maps v0.1.2 // indirect
	github.com/mitchellh/copystructure v1.2.0 // indirect
	github.com/mitchellh/reflectwalk v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/sagikazarmark/locafero v0.11.0 // indirect
	github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
	github.com/spf13/afero v1.15.0 // indirect
	github.com/spf13/cast v1.10.0 // indirect
	github.com/spf13/pflag v1.0.10 // indirect
	github.com/subosito/gotenv v1.6.0 // indirect
	go.uber.org/multierr v1.10.0 // indirect
	go.yaml.in/yaml/v3 v3.0.4 // indirect
	golang.org/x/sys v0.29.0 // indirect
	golang.org/x/text v0.28.0 // indirect
)

tools/configlog/go.sum ​

代码块 · 69 行 · 点击收起 / 展开
text
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

tools/configlog/main.go ​

代码块 · 65 行 · 点击收起 / 展开
go
package main

import (
	"bytes"
	"fmt"
	"github.com/knadh/koanf/providers/confmap"
	"github.com/knadh/koanf/v2"
	"github.com/spf13/viper"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"log/slog"
	"os"
	"strings"
)

func run() error {
	v := viper.New()
	v.SetDefault("port", 8080)
	v.SetConfigType("json")
	if err := v.ReadConfig(strings.NewReader(`{"port":9000}`)); err != nil {
		return err
	}
	if err := os.Setenv("ENGINEERING_V2_PORT", "9001"); err != nil {
		return err
	}
	if err := v.BindEnv("port", "ENGINEERING_V2_PORT"); err != nil {
		return err
	}
	fmt.Println("viper env=", v.GetInt("port"))
	v.Set("port", 9002)
	fmt.Println("viper explicit=", v.GetInt("port"))
	k := koanf.New(".")
	if err := k.Load(confmap.Provider(map[string]interface{}{"port": 9000}, "."), nil); err != nil {
		return err
	}
	if err := k.Load(confmap.Provider(map[string]interface{}{"port": 9001}, "."), nil); err != nil {
		return err
	}
	fmt.Println("koanf later=", k.Int("port"))
	var out bytes.Buffer
	enc := zap.NewProductionEncoderConfig()
	enc.TimeKey = ""
	z := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(enc), zapcore.AddSync(&out), zap.InfoLevel))
	z.Info("ready", zap.Int("port", 9001))
	if err := z.Sync(); err != nil {
		return err
	}
	fmt.Print("zap ", out.String())
	out.Reset()
	s := slog.New(slog.NewJSONHandler(&out, &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
		if a.Key == slog.TimeKey {
			return slog.Attr{}
		}
		return a
	}}))
	s.Info("ready", slog.Int("port", 9001))
	fmt.Print("slog ", out.String())
	return nil
}
func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

tools/configlog/main_test.go ​

go
package main

import "testing"

func TestRun(t *testing.T) {
	t.Setenv("ENGINEERING_V2_PORT", "")
	if err := run(); err != nil {
		t.Fatal(err)
	}
}

完整配置示意:未执行 ​

以下文件用于理解对应工具入口。lint、Air、GoReleaser 的配置需要放到目标应用模块根目录;本例可选 tools/cli。Wire 和容器测试各有独立 go.mod,尚未产生 go.sum,运行前需要在支持环境中下载并核验依赖。Wire 需额外执行生成器才有生成实现;Testcontainers 需 Docker 和经过核准的测试镜像。不要把这些文件的存在当作运行通过。

tools/recipes/.air.toml ​

toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/shop ."
bin = "tmp/shop"
include_ext = ["go"]
exclude_dir = ["tmp", "vendor", "dist"]
stop_on_error = true

tools/recipes/.golangci.yml ​

yaml
version: "2"
linters:
  default: none
  enable:
    - govet
    - ineffassign
    - unused

tools/recipes/.goreleaser.yaml ​

yaml
version: 2
builds:
  - id: shop
    main: .
    binary: shop
    env:
      - CGO_ENABLED=0
    goos:
      - linux
    goarch:
      - amd64
      - arm64

tools/recipes/containers/go.mod ​

go.mod
module example.com/container-recipe

go 1.27.2

require github.com/testcontainers/testcontainers-go v0.39.0

tools/recipes/containers/redis_test.go ​

代码块 · 63 行 · 点击收起 / 展开
go
package recipe

import (
	"bufio"
	"context"
	"fmt"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
	"net"
	"testing"
	"time"
)

func TestRedisPing(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "redis:7.4.2-alpine",
			ExposedPorts: []string{"6379/tcp"},
			WaitingFor:   wait.ForListeningPort("6379/tcp"),
		}, Started: true,
	})
	if c != nil {
		t.Cleanup(func() {
			closeCtx, closeCancel := context.WithTimeout(context.Background(), 10*time.Second)
			defer closeCancel()
			if err := c.Terminate(closeCtx); err != nil {
				t.Error(err)
			}
		})
	}
	if err != nil {
		t.Fatal(err)
	}

	host, err := c.Host(ctx)
	if err != nil {
		t.Fatal(err)
	}
	port, err := c.MappedPort(ctx, "6379/tcp")
	if err != nil {
		t.Fatal(err)
	}
	conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port.Port()), 5*time.Second)
	if err != nil {
		t.Fatal(err)
	}
	defer conn.Close()
	if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
		t.Fatal(err)
	}
	if _, err := fmt.Fprint(conn, "*1\r\n$4\r\nPING\r\n"); err != nil {
		t.Fatal(err)
	}
	reply, err := bufio.NewReader(conn).ReadString('\n')
	if err != nil {
		t.Fatal(err)
	}
	if reply != "+PONG\r\n" {
		t.Fatalf("reply=%q", reply)
	}
}

tools/recipes/wire/app.go ​

go
package recipe

type Store struct{}
type Service struct{ store *Store }

func NewStore() *Store             { return &Store{} }
func NewService(s *Store) *Service { return &Service{store: s} }

tools/recipes/wire/go.mod ​

go.mod
module example.com/wire-recipe

go 1.27.2

require github.com/google/wire v0.7.0

tools/recipes/wire/wire.go ​

go
//go:build wireinject

package recipe

import "github.com/google/wire"

func Initialize() *Service {
	wire.Build(NewStore, NewService)
	return nil
}

保留各书来源与许可 · 示例运行条件以正文为准